Coverage Report

Created: 2026-04-26 08:04

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\csshw\csshw\xtask\src\release.rs
Line
Count
Source
1
//! Release preparation and git tag creation.
2
//!
3
//! [`prepare_release`] bumps the version, optionally creates a maintenance
4
//! branch, updates `Cargo.toml` and `Cargo.lock`, generates the changelog,
5
//! commits, and pushes.
6
//!
7
//! [`create_release_tag`] validates the current state and creates an annotated
8
//! git tag that triggers the GitHub Actions release workflow.
9
10
use anyhow::{bail, Context, Result};
11
use semver::Version;
12
13
/// Type of version increment for a release.
14
#[derive(Debug, PartialEq)]
15
pub enum ReleaseType {
16
    /// Increment the major component (X.0.0).
17
    Major,
18
    /// Increment the minor component (0.X.0).
19
    Minor,
20
    /// Increment the patch component (0.0.X).
21
    Patch,
22
}
23
24
impl std::fmt::Display for ReleaseType {
25
4
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26
4
        match self {
27
0
            ReleaseType::Major => write!(f, "major"),
28
2
            ReleaseType::Minor => write!(f, "minor"),
29
2
            ReleaseType::Patch => write!(f, "patch"),
30
        }
31
4
    }
32
}
33
34
/// All side-effecting operations required by this module.
35
///
36
/// Each method maps to exactly one external operation, making every step
37
/// independently mockable in tests.
38
pub trait ReleaseSystem {
39
    /// Run `git status --porcelain` and return its stdout.
40
    ///
41
    /// # Errors
42
    ///
43
    /// Returns an error if the process fails.
44
    fn git_status_porcelain(&self) -> Result<String>;
45
46
    /// Return the current git branch name.
47
    ///
48
    /// # Errors
49
    ///
50
    /// Returns an error if the process fails.
51
    fn git_current_branch(&self) -> Result<String>;
52
53
    /// Create and switch to a new branch with `git checkout -b <name>`.
54
    ///
55
    /// # Arguments
56
    ///
57
    /// * `name` - Branch name to create.
58
    ///
59
    /// # Errors
60
    ///
61
    /// Returns an error if the process fails.
62
    fn git_checkout_new_branch(&self, name: &str) -> Result<()>;
63
64
    /// Stage the given files with `git add`.
65
    ///
66
    /// # Arguments
67
    ///
68
    /// * `files` - Paths to stage.
69
    ///
70
    /// # Errors
71
    ///
72
    /// Returns an error if the process fails.
73
    fn git_add(&self, files: &[String]) -> Result<()>;
74
75
    /// Commit staged changes with the given message.
76
    ///
77
    /// # Arguments
78
    ///
79
    /// * `message` - Commit message.
80
    /// * `no_verify` - When `true`, pass `--no-verify` to bypass git hooks.
81
    ///
82
    /// # Errors
83
    ///
84
    /// Returns an error if the process fails.
85
    fn git_commit(&self, message: &str, no_verify: bool) -> Result<()>;
86
87
    /// Run `git push` with the given extra arguments.
88
    ///
89
    /// # Arguments
90
    ///
91
    /// * `args` - Extra arguments appended to `git push`.
92
    ///
93
    /// # Errors
94
    ///
95
    /// Returns an error if the process fails.
96
    fn git_push(&self, args: &[String]) -> Result<()>;
97
98
    /// Return `git tag -l <tag>` stdout for the given tag name.
99
    ///
100
    /// # Arguments
101
    ///
102
    /// * `tag` - Tag name to check.
103
    ///
104
    /// # Errors
105
    ///
106
    /// Returns an error if the process fails.
107
    fn git_tag_list(&self, tag: &str) -> Result<String>;
108
109
    /// Return the subject of the latest commit (`git log -1 --pretty=format:%s`).
110
    ///
111
    /// # Errors
112
    ///
113
    /// Returns an error if the process fails.
114
    fn git_log_latest_subject(&self) -> Result<String>;
115
116
    /// Run `git fetch`.
117
    ///
118
    /// # Errors
119
    ///
120
    /// Returns an error if the process fails (non-fatal; callers may continue).
121
    fn git_fetch(&self) -> Result<()>;
122
123
    /// Return the number of commits the local branch is behind `<branch>` on
124
    /// the remote.
125
    ///
126
    /// # Arguments
127
    ///
128
    /// * `branch` - Remote branch to compare against.
129
    ///
130
    /// # Errors
131
    ///
132
    /// Returns an error if the process fails.
133
    fn git_rev_list_count_behind(&self, branch: &str) -> Result<u32>;
134
135
    /// Create an annotated git tag.
136
    ///
137
    /// # Arguments
138
    ///
139
    /// * `tag` - Tag name.
140
    /// * `message` - Annotation message.
141
    ///
142
    /// # Errors
143
    ///
144
    /// Returns an error if the process fails.
145
    fn git_create_annotated_tag(&self, tag: &str, message: &str) -> Result<()>;
146
147
    /// Push a tag to `origin`.
148
    ///
149
    /// # Arguments
150
    ///
151
    /// * `tag` - Tag name to push.
152
    ///
153
    /// # Errors
154
    ///
155
    /// Returns an error if the process fails.
156
    fn git_push_tag(&self, tag: &str) -> Result<()>;
157
158
    /// Read the contents of `Cargo.toml`.
159
    ///
160
    /// # Errors
161
    ///
162
    /// Returns an error if the file cannot be read.
163
    fn read_cargo_toml(&self) -> Result<String>;
164
165
    /// Write `content` to `Cargo.toml`.
166
    ///
167
    /// # Errors
168
    ///
169
    /// Returns an error if the write fails.
170
    fn write_cargo_toml(&self, content: &str) -> Result<()>;
171
172
    /// Run `cargo update --workspace` to refresh `Cargo.lock`.
173
    ///
174
    /// # Errors
175
    ///
176
    /// Returns an error if the process fails.
177
    fn cargo_update_workspace(&self) -> Result<()>;
178
179
    /// Generate the changelog for the current version.
180
    ///
181
    /// # Errors
182
    ///
183
    /// Returns an error if changelog generation fails.
184
    fn generate_changelog(&self) -> Result<()>;
185
186
    /// Display `message` and read a line of user input.
187
    ///
188
    /// # Arguments
189
    ///
190
    /// * `message` - Prompt text.
191
    ///
192
    /// # Returns
193
    ///
194
    /// The trimmed response string.
195
    ///
196
    /// # Errors
197
    ///
198
    /// Returns an error if stdin cannot be read.
199
    fn prompt_user(&self, message: &str) -> Result<String>;
200
}
201
202
/// Production implementation of [`ReleaseSystem`].
203
pub struct RealSystem;
204
205
#[cfg_attr(coverage_nightly, coverage(off))]
206
impl ReleaseSystem for RealSystem {
207
    fn git_status_porcelain(&self) -> Result<String> {
208
        let output = std::process::Command::new("git")
209
            .args(["status", "--porcelain"])
210
            .output()
211
            .context("failed to run `git status --porcelain`")?;
212
        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
213
    }
214
215
    fn git_current_branch(&self) -> Result<String> {
216
        let output = std::process::Command::new("git")
217
            .args(["branch", "--show-current"])
218
            .output()
219
            .context("failed to run `git branch --show-current`")?;
220
        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
221
    }
222
223
    fn git_checkout_new_branch(&self, name: &str) -> Result<()> {
224
        let status = std::process::Command::new("git")
225
            .args(["checkout", "-b", name])
226
            .status()
227
            .context("failed to run `git checkout -b`")?;
228
        if !status.success() {
229
            bail!("`git checkout -b {name}` failed with status {status}");
230
        }
231
        Ok(())
232
    }
233
234
    fn git_add(&self, files: &[String]) -> Result<()> {
235
        let status = std::process::Command::new("git")
236
            .arg("add")
237
            .args(files)
238
            .status()
239
            .context("failed to run `git add`")?;
240
        if !status.success() {
241
            bail!("`git add` failed with status {status}");
242
        }
243
        Ok(())
244
    }
245
246
    fn git_commit(&self, message: &str, no_verify: bool) -> Result<()> {
247
        let mut cmd = std::process::Command::new("git");
248
        cmd.args(["commit", "-m", message]);
249
        if no_verify {
250
            cmd.arg("--no-verify");
251
        }
252
        let status = cmd.status().context("failed to run `git commit`")?;
253
        if !status.success() {
254
            bail!("`git commit` failed with status {status}");
255
        }
256
        Ok(())
257
    }
258
259
    fn git_push(&self, args: &[String]) -> Result<()> {
260
        let status = std::process::Command::new("git")
261
            .arg("push")
262
            .args(args)
263
            .status()
264
            .context("failed to run `git push`")?;
265
        if !status.success() {
266
            bail!("`git push` failed with status {status}");
267
        }
268
        Ok(())
269
    }
270
271
    fn git_tag_list(&self, tag: &str) -> Result<String> {
272
        let output = std::process::Command::new("git")
273
            .args(["tag", "-l", tag])
274
            .output()
275
            .context("failed to run `git tag -l`")?;
276
        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
277
    }
278
279
    fn git_log_latest_subject(&self) -> Result<String> {
280
        let output = std::process::Command::new("git")
281
            .args(["log", "-1", "--pretty=format:%s"])
282
            .output()
283
            .context("failed to run `git log`")?;
284
        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
285
    }
286
287
    fn git_fetch(&self) -> Result<()> {
288
        let status = std::process::Command::new("git")
289
            .arg("fetch")
290
            .status()
291
            .context("failed to run `git fetch`")?;
292
        if !status.success() {
293
            bail!("`git fetch` failed with status {status}");
294
        }
295
        Ok(())
296
    }
297
298
    fn git_rev_list_count_behind(&self, branch: &str) -> Result<u32> {
299
        let output = std::process::Command::new("git")
300
            .args(["rev-list", "--count", &format!("HEAD..origin/{branch}")])
301
            .output()
302
            .context("failed to run `git rev-list`")?;
303
        let count = String::from_utf8_lossy(&output.stdout)
304
            .trim()
305
            .parse::<u32>()
306
            .unwrap_or(0);
307
        Ok(count)
308
    }
309
310
    fn git_create_annotated_tag(&self, tag: &str, message: &str) -> Result<()> {
311
        let status = std::process::Command::new("git")
312
            .args(["tag", "-a", tag, "-m", message])
313
            .status()
314
            .context("failed to run `git tag -a`")?;
315
        if !status.success() {
316
            bail!("`git tag -a {tag}` failed with status {status}");
317
        }
318
        Ok(())
319
    }
320
321
    fn git_push_tag(&self, tag: &str) -> Result<()> {
322
        let status = std::process::Command::new("git")
323
            .args(["push", "origin", tag])
324
            .status()
325
            .context("failed to run `git push origin <tag>`")?;
326
        if !status.success() {
327
            bail!("`git push origin {tag}` failed with status {status}");
328
        }
329
        Ok(())
330
    }
331
332
    fn read_cargo_toml(&self) -> Result<String> {
333
        std::fs::read_to_string("Cargo.toml").context("failed to read Cargo.toml")
334
    }
335
336
    fn write_cargo_toml(&self, content: &str) -> Result<()> {
337
        std::fs::write("Cargo.toml", content).context("failed to write Cargo.toml")
338
    }
339
340
    fn cargo_update_workspace(&self) -> Result<()> {
341
        let status = std::process::Command::new("cargo")
342
            .args(["update", "--workspace"])
343
            .status()
344
            .context("failed to run `cargo update --workspace`")?;
345
        if !status.success() {
346
            bail!("`cargo update --workspace` failed with status {status}");
347
        }
348
        Ok(())
349
    }
350
351
    fn generate_changelog(&self) -> Result<()> {
352
        crate::changelog::generate_changelog(&crate::changelog::RealSystem)
353
    }
354
355
    fn prompt_user(&self, message: &str) -> Result<String> {
356
        use std::io::Write;
357
        print!("{message}");
358
        std::io::stdout()
359
            .flush()
360
            .context("failed to flush stdout")?;
361
        let mut input = String::new();
362
        std::io::stdin()
363
            .read_line(&mut input)
364
            .context("failed to read user input")?;
365
        Ok(input.trim().to_owned())
366
    }
367
}
368
369
/// Determine the suggested next version and release type from the current branch.
370
///
371
/// `main` → minor bump; `*-maintenance` → patch bump.
372
///
373
/// # Arguments
374
///
375
/// * `current` - Current version from `Cargo.toml`.
376
/// * `branch` - Current git branch name.
377
///
378
/// # Returns
379
///
380
/// `(ReleaseType, next_version)`.
381
///
382
/// # Errors
383
///
384
/// Returns an error when `branch` is neither `main` nor ends with
385
/// `-maintenance`.
386
6
pub fn suggest_next_version(current: &Version, branch: &str) -> Result<(ReleaseType, Version)> {
387
6
    if branch == "main" {
388
2
        let mut next = current.clone();
389
2
        next.minor += 1;
390
2
        next.patch = 0;
391
2
        Ok((ReleaseType::Minor, next))
392
4
    } else if branch.ends_with("-maintenance") {
393
2
        let mut next = current.clone();
394
2
        next.patch += 1;
395
2
        Ok((ReleaseType::Patch, next))
396
    } else {
397
2
        bail!(
398
            "must be on 'main' or a '*-maintenance' branch to prepare a release \
399
             (current branch: {branch})"
400
        )
401
    }
402
6
}
403
404
/// Determine the release type by comparing two versions.
405
///
406
/// # Arguments
407
///
408
/// * `current` - The version before the release.
409
/// * `next` - The version after the release.
410
///
411
/// # Returns
412
///
413
/// The most significant component that changed.
414
3
pub fn determine_release_type(current: &Version, next: &Version) -> ReleaseType {
415
3
    if next.major > current.major {
416
1
        ReleaseType::Major
417
2
    } else if next.minor > current.minor {
418
1
        ReleaseType::Minor
419
    } else {
420
1
        ReleaseType::Patch
421
    }
422
3
}
423
424
/// Rewrite the `[package].version` field in a `Cargo.toml` string.
425
///
426
/// Uses `toml_edit` to preserve all existing formatting.
427
///
428
/// # Arguments
429
///
430
/// * `cargo_toml_content` - Raw TOML text of `Cargo.toml`.
431
/// * `new_version` - Version string to set.
432
///
433
/// # Returns
434
///
435
/// Updated TOML text.
436
///
437
/// # Errors
438
///
439
/// Returns an error if `cargo_toml_content` cannot be parsed as TOML.
440
4
pub fn set_cargo_toml_version(cargo_toml_content: &str, new_version: &str) -> Result<String> {
441
4
    let mut doc: toml_edit::Document = cargo_toml_content
442
4
        .parse()
443
4
        .context("failed to parse Cargo.toml")
?0
;
444
4
    doc["package"]["version"] = toml_edit::value(new_version);
445
4
    Ok(doc.to_string())
446
4
}
447
448
/// Prepare a new release.
449
///
450
/// Full workflow:
451
/// 1. Verify working tree is clean.
452
/// 2. Detect branch and suggest release type / next version.
453
/// 3. Prompt user (accepts custom version input).
454
/// 4. Create maintenance branch if on `main`.
455
/// 5. Update `Cargo.toml` version.
456
/// 6. Run `cargo update --workspace`.
457
/// 7. Generate changelog.
458
/// 8. Commit and push.
459
///
460
/// # Arguments
461
///
462
/// * `system` - Injected I/O provider.
463
///
464
/// # Errors
465
///
466
/// Returns an error if any step fails.
467
4
pub fn prepare_release<S: ReleaseSystem>(system: &S) -> Result<()> {
468
4
    let status = system.git_status_porcelain()
?0
;
469
4
    if !status.trim().is_empty() {
470
1
        bail!("git working directory is not clean — commit or stash changes first:\n{status}");
471
3
    }
472
473
3
    let current_branch = system.git_current_branch()
?0
;
474
3
    let cargo_toml = system.read_cargo_toml()
?0
;
475
3
    let current_version: Version = crate::changelog::extract_version_from_cargo_toml(&cargo_toml)
?0
476
3
        .parse()
477
3
        .context("failed to parse current version as semver")
?0
;
478
479
3
    println!("INFO - Current branch: {current_branch}");
480
3
    println!("INFO - Current version: {current_version}");
481
482
2
    let (suggested_type, suggested_version) =
483
3
        suggest_next_version(&current_version, &current_branch)
?1
;
484
485
2
    let prompt = format!(
486
        "Preparing {suggested_type} release: {current_version} -> {suggested_version}. Continue? [Y/n]: "
487
    );
488
2
    let answer = system.prompt_user(&prompt)
?0
;
489
490
2
    let (next_version, actual_type) =
491
2
        if answer.eq_ignore_ascii_case("n") || answer.eq_ignore_ascii_case("no") {
492
0
            let custom_str = system.prompt_user(&format!(
493
0
                "Enter custom version (current: {current_version}): "
494
0
            ))?;
495
0
            if custom_str.is_empty() {
496
0
                bail!("version cannot be empty");
497
0
            }
498
0
            let custom: Version = custom_str
499
0
                .parse()
500
0
                .context("invalid version format — use semantic versioning (e.g. 1.2.3)")?;
501
0
            let release_type = determine_release_type(&current_version, &custom);
502
0
            (custom, release_type)
503
2
        } else if answer.is_empty()
504
2
            || answer.eq_ignore_ascii_case("y")
505
0
            || answer.eq_ignore_ascii_case("yes")
506
        {
507
2
            (suggested_version, suggested_type)
508
        } else {
509
0
            bail!("invalid input — please enter Y or n");
510
        };
511
512
2
    let target_branch = if current_branch == "main" {
513
1
        format!("{}.{}-maintenance", next_version.major, next_version.minor)
514
    } else {
515
1
        current_branch.clone()
516
    };
517
518
2
    println!("INFO - Preparing {actual_type} release: {current_version} -> {next_version}");
519
2
    println!("INFO - Target branch: {target_branch}");
520
521
2
    if current_branch == "main" {
522
1
        println!("INFO - Creating maintenance branch: {target_branch}");
523
1
        system.git_checkout_new_branch(&target_branch)
?0
;
524
1
    }
525
526
2
    println!("INFO - Updating Cargo.toml version to {next_version}");
527
2
    let updated_cargo = set_cargo_toml_version(&cargo_toml, &next_version.to_string())
?0
;
528
2
    system.write_cargo_toml(&updated_cargo)
?0
;
529
530
2
    println!("INFO - Updating Cargo.lock");
531
2
    system.cargo_update_workspace()
?0
;
532
533
2
    println!("INFO - Generating changelog");
534
2
    system.generate_changelog()
?0
;
535
536
2
    let commit_message = format!("Version {next_version}");
537
2
    println!("INFO - Committing: {commit_message}");
538
2
    system.git_add(&[
539
2
        "Cargo.toml".to_owned(),
540
2
        "Cargo.lock".to_owned(),
541
2
        "CHANGELOG.md".to_owned(),
542
2
        "changelogging.toml".to_owned(),
543
2
    ])
?0
;
544
    // Skip pre-commit hooks: the project's hook runs `cargo build --workspace
545
    // --all-targets`, which would try to replace the running xtask.exe and
546
    // fail on Windows with an access-denied error.
547
2
    system.git_commit(&commit_message, true)
?0
;
548
549
2
    println!("INFO - Pushing to remote");
550
2
    if current_branch == "main" {
551
1
        system.git_push(&["-u".to_owned(), "origin".to_owned(), target_branch.clone()])
?0
;
552
    } else {
553
1
        system.git_push(&[])
?0
;
554
    }
555
556
2
    println!("INFO - Release {next_version} prepared on branch {target_branch}");
557
2
    println!("INFO - Run `cargo xtask create-release-tag` to tag the release");
558
2
    Ok(())
559
4
}
560
561
/// Create and push an annotated git tag for the current release version.
562
///
563
/// Full workflow:
564
/// 1. Verify on a maintenance branch.
565
/// 2. Read version from `Cargo.toml`.
566
/// 3. Check the tag does not already exist.
567
/// 4. Verify the latest commit message is `"Version X.Y.Z"`.
568
/// 5. Fetch from remote and check not behind.
569
/// 6. Prompt user for confirmation.
570
/// 7. Create annotated tag and push.
571
///
572
/// # Arguments
573
///
574
/// * `system` - Injected I/O provider.
575
///
576
/// # Errors
577
///
578
/// Returns an error if any validation step fails.
579
6
pub fn create_release_tag<S: ReleaseSystem>(system: &S) -> Result<()> {
580
6
    let current_branch = system.git_current_branch()
?0
;
581
6
    if !current_branch.ends_with("-maintenance") {
582
1
        bail!(
583
            "must be on a maintenance branch to create a release tag \
584
             (current branch: {current_branch}) — run `cargo xtask prepare-release` first"
585
        );
586
5
    }
587
588
5
    let cargo_toml = system.read_cargo_toml()
?0
;
589
5
    let version_str = crate::changelog::extract_version_from_cargo_toml(&cargo_toml)
?0
;
590
5
    let version: Version = version_str
591
5
        .parse()
592
5
        .context("failed to parse version as semver")
?0
;
593
594
5
    println!("INFO - Current branch: {current_branch}");
595
5
    println!("INFO - Version to tag: {version}");
596
597
5
    let existing_tag = system.git_tag_list(&version.to_string())
?0
;
598
5
    if !existing_tag.trim().is_empty() {
599
1
        bail!("tag {version} already exists");
600
4
    }
601
602
4
    let commit_msg = system.git_log_latest_subject()
?0
;
603
4
    let expected_msg = format!("Version {version}");
604
4
    if commit_msg != expected_msg {
605
1
        bail!(
606
            "latest commit message does not match expected version commit\n\
607
             expected: {expected_msg}\n\
608
             actual:   {commit_msg}\n\
609
             run `cargo xtask prepare-release` first"
610
        );
611
3
    }
612
613
3
    println!("INFO - Fetching latest changes from remote");
614
3
    if let Err(
e0
) = system.git_fetch() {
615
0
        eprintln!("WARN - Failed to fetch from remote, continuing anyway: {e}");
616
3
    }
617
618
3
    let behind = system.git_rev_list_count_behind(&current_branch)
?0
;
619
3
    if behind > 0 {
620
1
        bail!("local branch is {behind} commit(s) behind remote — run `git pull` first");
621
2
    }
622
623
2
    let answer = system.prompt_user(&format!(
624
2
        "About to create and push tag '{version}'. Continue? [Y/n]: "
625
2
    ))
?0
;
626
2
    if answer.eq_ignore_ascii_case("n") || 
answer1
.eq_ignore_ascii_case("no") {
627
1
        println!("INFO - Tag creation cancelled");
628
1
        return Ok(());
629
1
    }
630
631
1
    let tag_message = format!("Version {version}");
632
1
    println!("INFO - Creating annotated tag: {version}");
633
1
    system.git_create_annotated_tag(&version.to_string(), &tag_message)
?0
;
634
635
1
    println!("INFO - Pushing tag to remote");
636
1
    system.git_push_tag(&version.to_string())
?0
;
637
638
1
    println!("INFO - Tag '{version}' created and pushed");
639
1
    println!("INFO - Check: https://github.com/whme/csshw/actions/workflows/release.yml");
640
1
    Ok(())
641
6
}
642
643
#[cfg(test)]
644
#[path = "tests/test_release.rs"]
645
mod tests;